iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

Day 8

Day 7 談的是身份。員編與 LINE 最後都必須收斂到同一個 canonical user。身份一旦開始穩定,另一個問題就變得更明顯:這個人現在到底還有多少錢?

一開始,這題看起來很簡單。User 上放一個 balance,畫面把它顯示出來;下單就扣掉,儲值就加回去。如果系統只是表單,這種做法完全合理。

但只要開始出現儲值、下單扣款、人工調整、舊資料補正與歷史查詢,「目前餘額」就不再只是 User 上的一個欄位。接下來要回答的,已經從「現在是多少?」變成「為什麼現在是這個數字?」

一旦系統開始替使用者保管餘額,資料責任就從保存一個數字,升級成保存這個數字如何形成的證據。


問題不是 balance 算錯,而是系統同時有兩個答案

最簡單的模型會直接保存 users.balance。它很適合快速回答目前餘額。

但當系統開始記錄每一筆儲值與扣款,又會出現另一套資料:TOPUP、ORDER、ADJUSTMENT,以及每筆異動之後的 balance_after。

兩份資料同時存在沒有問題;麻煩的是兩邊不一致時,到底要相信誰?

先用一個簡化例子:假設 users.balance 顯示 800,但最新一筆交易紀錄的 balance_after 是 700。這只是用來說明資料權威衝突,不是在描述某一筆 Production Incident。這時候不能只說「有一個數字錯了,改成一樣就好」。因為必須先回答:哪一份才有權定義現在?

如果這件事沒有先決定,每個 API、每個畫面、每個管理功能,都可能各自選一個自己相信的來源。那麼餘額就不再是一個資料欄位,而是多個系統元件之間的意見。


先決定誰才是 Balance Authority

這套系統最後形成了一個很重要的規則:只要已經存在有順序的 Ledger,最新一筆 sequenced ledger 就是餘額的 canonical authority。

users.balance 則保留成 mirror / fallback;當 sequenced ledger 已存在時,它不再是主要權威。

資料 主要責任
Ledger 解釋餘額如何一路變成現在這個數字
Latest sequenced ledger 回答 canonical current balance
users.balance 快速 mirror;沒有 sequenced ledger 時才 fallback
Diagnostics 找出歷史 discontinuity、異常與對帳問題

這一步是在正式回答:到底誰有資格說「這是目前餘額」。


為什麼還需要 sequence?時間不就夠了嗎?

一開始很容易想到:每筆交易都有 timestamp,照時間排序不就好了?如果所有資料永遠由同一條乾淨流程寫入,可能真的夠用。

但真實系統還會碰到舊資料匯入、補寫、修正與不同流程寫入。created_at 和帳務實際順序,不一定永遠是同一件事。

因此 Ledger 後來不只需要「有哪些交易」,還需要明確 sequence。每一筆都能留下 balance_before、amount 與 balance_after,並檢查 balance_before + amount = balance_after;下一筆也應該能接上前一筆的 balance_after。

當這條鏈成立時,「現在有多少」不再是孤立數字,而是一條可以往回追的路。


歷史資料不會從第一天就自動乾淨

這套便當系統不是從完整帳務模型開始。早期先有使用者餘額、訂單、舊系統資料與儲值紀錄,後來才逐步把 Ledger 與 sequence 補完整。

因此在整理歷史資料時,真的會遇到前一筆 balance_after 與下一筆 balance_before 對不起來的 Ledger chain discontinuity。

這時最危險的直覺,是把全部 amount 重新加總,算出一個看起來更合理的答案,再直接蓋回去。

問題是,歷史可能包含舊系統 cutoff、技術性 restore、人工 adjustment、migration 中繼狀態,甚至某段資料本來就缺少完整起點。重新算得出另一個數字,不代表那個數字自然就比既有帳務狀態更有權威。

所以系統採取比較保守的做法:Diagnostics 可以指出歷史 chain 有問題,但不能偷偷用重新計算的結果取代 canonical closing balance。

也就是把兩件事分開:一,現在帳上認定多少;二,歷史鏈條是否完全自洽。兩者都要回答,但不能混成一題。


一筆 Adjustment 讓交易語意的重要性浮出來

Ledger 裡還有另一個很容易踩坑的地方。假設看到 ADJUSTMENT +800,直覺會把它理解成「使用者增加了 800 元」。如果月報直接把它算進本月儲值,數字看起來也很合理。

但某些 Adjustment 的用途,是補回 legacy cutoff 時缺少的 balance baseline,也就是 technical restore。

如果這種 restore 被當成一般 top-up,current balance 也許仍然正確,但「這個月加了多少錢」就會被放大。

所以 Ledger 不只有 amount,還需要 semantic。這套系統目前可以直接驗證的例子包括:TOPUP 是儲值、ORDER 是訂單扣款、ADJUSTMENT 則可能是校正。未來若加入退款或其他反向異動,也應該保留自己的交易語意,而不是只靠金額正負判斷。技術性 restore 更不能直接用正數就推論成一般儲值。

正負號只告訴你數學方向,沒有告訴你這筆錢為什麼出現。


金額進來後,最好把「事實」和「解讀」分開

這次 Ledger 整理最後留下了一個很實用的三層分工。

Day 8 Ledger|Transaction Fact → Balance Projection → Presentation / Summary

Transaction Fact 保存異動事實;Balance Projection 由 latest sequenced ledger 定義 canonical balance;Presentation / Summary 再把同一份事實轉成使用者可理解的語意。

第一層:Transaction Fact

保存 amount、balance_before、balance_after、sequence、type 與 reference,回答「發生了什麼」。

第二層:Balance Projection

由 latest sequenced ledger 得到 current balance,回答「現在是多少」。

第三層:Presentation / Summary

把原始交易翻成「本月儲值、本月消費、歷史餘額校正」等可理解語意,回答「要怎麼讓使用者理解」。

如果三層混在一起,Frontend 很容易只看到正數就自己把 technical restore 翻成「儲值 +800」。當責任拆開後,畫面就不必重新猜這筆交易的語意。


Balance API 還得留下追查線索

一般使用者畫面可能只需要目前餘額與可讀的交易描述。但 Debug 或管理場景還需要 sequence、balance_before、balance_after、reconciliation、discontinuity 與來源資訊。

這些 diagnostics 不一定全部直接暴露給一般前端,可是系統本身必須保留足夠 Evidence,讓問題發生時能追。

這也是金額資料和普通表單欄位最大的差別之一:除了答案,還要留下答案是怎麼來的。


Ledger-first 不代表所有系統都要做成會計系統

這套便當系統管理的是內部預付餘額,不是銀行核心,也不是總帳系統。

這裡需要的,是一個清楚的 balance authority、可追溯的異動順序、保留原事實的 correction,以及能定位不一致的 diagnostics。

如果系統只做單次付款,並由外部金流完成、自己不保存內部餘額,可能根本不需要這套 Ledger。反過來,只要開始承擔「儲值 → 保留餘額 → 多次扣款 → 調整 → 歷史查詢」,只存一個 balance 通常就不夠了。


可以用五個問題判斷餘額是否已經需要 Ledger

  1. 使用者會不會問「為什麼剩這些錢」?如果會,就不能只保留最後答案。
  2. 同一個 balance 會不會被 TOPUP、ORDER、ADJUSTMENT 等不同事件改變?若未來還會加入退款等反向異動,更需要先定義交易語意。
  3. 有沒有歷史資料、migration 或人工修正?
  4. User API、Admin Summary、Order flow、Frontend cache 會不會讀到不同餘額來源?
  5. 數字不一致時,有沒有 Evidence 可以查?

如果最後只能說「大概是某次儲值沒同步到」,那就代表系統其實沒有足夠的帳務可觀察性。


Day 8 改變的是資料責任

如果把這段演進只寫成「以前 users.balance,後來新增 balance_ledger」,會漏掉核心:資料責任已經變了。

這次的演進是:一個數字 → 一串交易 → 一個有順序的 Ledger → 明確的 Balance Authority → 可追溯的 Diagnostics。

對普通表單來說,把值更新成最新版可能就夠了;但對餘額來說,系統還必須回答「怎麼變成這樣」以及「如果兩份資料不同,誰才算數」。這也是 Day 8 從欄位更新走向 Ledger 的原因。


本系列實作專案

這不是純概念示範,而是一套持續開發中的便當訂購系統。文章著重「為什麼」,GitHub 保留實際程式碼、文件與演進紀錄。

GitHub:https://github.com/henryfir456/bento-order-app


下一篇

Ledger 解決的是:金額如何一路變成現在的狀態。

但系統裡還有另一種資料,也不能永遠只保留最新版。例如今天看到的一份菜單,下個月價格改了之後,過去訂單到底應該看到哪一版?

下一篇會繼續沿著「歷史不能被現在覆蓋」這件事往下走,但主角會從金額換成另一個每天都看得到的東西:菜單。


上一篇
Day 7|一套便當系統,為什麼最後會碰到身份系統?
下一篇
Day 9|菜單不是一張圖片,它是一段歷史
系列文
從 GAS 到 Cloudflare D1:AI Agent 如何打造、接手並重構真實便當系統9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言